Skip to content

Give types with an implicit numeric conversion Python's number protocol - #151

Merged
jhonabreul merged 2 commits into
QuantConnect:masterfrom
jhonabreul:feature-implicit-numeric-operators
Oct 28, 2026
Merged

jhonabreul merged 2 commits into
QuantConnect:masterfrom
jhonabreul:feature-implicit-numeric-operators

Conversation

@jhonabreul

@jhonabreul jhonabreul commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

What does this implement/fix? Explain your changes.

Rebased on master after #150 merged. The version bump to 2.0.67 came with #150, and this change ships in that same version, so there is no bump here.

A C# type with an implicit conversion to a number is usable as a number in C#, but not in Python:

unsupported operand type(s) for +: 'OrderFee' and 'OrderFee'
float() argument must be a string or a real number, not 'OrderFee'

Cause: Python evaluates a + b through type(a).__add__, pythonnet wires those slots from C# operator methods only, and an op_Implicit conversion is never consulted by an operator expression.

The change:

  • ImplicitNumericOps<T> is an [Ops] class like EnumOps<T>: arithmetic, comparison, unary negation, __float__ and __int__ methods delegating to the type's implicit numeric conversion.
  • ClassManager.GetClassInfo grafts those methods onto any type whose public static op_Implicit returns a numeric type, skipping operator names the type declares itself.
  • OperatorMethod.GetOperatorCandidates includes them so FixupSlots wires the number slots, and a new __float__ slot definition maps to nb_float.
  • Reverse comparisons are not synthesized; Python reflects 1 < x onto x > 1 by itself.

Does this close any currently open issues?

No.

Any other comments?

Tests, in TestImplicitNumericOperators:

  • Arithmetic between two convertible instances and against Python numbers on both sides, plus unary negation.
  • sum, max and sorted over convertible instances.
  • Forward and reflected comparisons against instances and numbers.
  • float() on a decimal-backed type, float() and int() on a double-backed type.
  • A type declaring its own + keeps it, while the undeclared - is synthesized.
  • A type without a conversion gets no __add__ or __float__.
  • Full embedding suite: 1009 passed, 0 failed.

@Martin-Molinero

Martin-Molinero commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Review follow-up: pushed 4444105, 62447d5 and ab9cc61

Where:

What and why

  1. Inherited operators were overridden. The filter checked local, which only holds names the type declares. If a base class declares op_Addition and the derived class adds the conversion, the grafted (T, T) overload was the closer match and won: d + d returned 4.0 instead of the base operator's result. C# would pick the base operator.
    • A first fix exposed a second bug: OperatorMethod.FixupSlots still installed nb_add on the derived type. That type then had no __add__ of its own, so CPython's slot wrapper called itself until RecursionError.
  2. Everything went through double.
    • int() on a type with an implicit long conversion lost precision: 2⁵³+1 came back as 9007199254740992.
    • Integer-backed types returned floats: OrderTicket has implicit operator int, so ticket + 1 gave 2.0.
    • Integer comparisons were off past 2⁵³.
  3. Division by zero returned inf. Python int/float raise ZeroDivisionError, and C# decimal throws.
  4. IComparable was overridden. tp_richcompare prefers operator methods over IComparable, so for types that have both, comparisons between two instances started going through double. Example: 1.0000000000000000000000000001m and 1m are equal as doubles, so a > b became False.

How

  • ImplicitNumericOps.GetMethods(type) now filters out operators the type declares or inherits. Both ClassManager and OperatorMethod.GetOperatorCandidates use this one list, which fixes items 1 and the slot recursion.
  • Values behave like the Python number their conversion maps to:
    • Integral conversions use exact BigInteger math and return Python ints. Results beyond long become a PyInt, so they're unbounded like Python ints. True division still returns a float.
    • float, double and decimal conversions return floats, as before.
  • The other operand is now taken as a PyObject and classified under the GIL. Two reasons:
    • The binder ranks double (precedence 3) above long (21), so typed (T, long)/(T, double) overloads would always produce floats.
    • An object parameter rejects any Python int beyond Int32. Converter's object path only tries Int32, because PyInt_Check catches every int before the Int64 branch. That's an existing converter bug worth fixing on its own. Without this change, fee + 2**31 would have regressed.
    • Accepted operands: instances of T, ints (including bool), floats, and anything with __index__ or __float__ (numpy scalars, other CLR types with a conversion). Anything else returns NotImplemented, so Python tries the other operand and raises its usual "unsupported operand" TypeError.
  • The GIL is taken explicitly, because this fork's binder always releases it ([ForbidPythonThreads] is disabled in MethodObject).
  • Division raises ZeroDivisionError("division by zero"). Returning inf instead would be a one-line change in Divide.
  • int() truncates decimals exactly, and raises ValueError/OverflowError on NaN/infinity, like Python.
  • Comparisons between instances of an IComparable type use Comparer<T>.Default. Comparisons against numbers stay numeric.

Performance: both versions were timed with the same embed test. Arithmetic went from about 1.7–1.8 µs to 1.2 µs per op, and comparisons from 2.8 µs to 1.6 µs, because there's now one overload per direction instead of three. A plain property read is about 0.2 µs for reference. These timings are rough: the full suite was running at the same time.

Tests

  • TestImplicitNumericOperators: 15/15. The 6 existing tests are unchanged and still pass. New tests cover:
    • int results for integral conversions, and floats where Python would give them
    • exact values past 2⁵³ and past long
    • int() on NaN, infinity and large decimals
    • ZeroDivisionError
    • ints beyond Int32 and other numeric types as operands
    • the TypeError for unsupported operands
    • inherited operators winning, and IComparable ordering winning
  • Full embed suite: 1020 passed, 0 failed (net10.0, Python 3.11), on the full stack including ab9cc61.
  • Lean Python regression algorithms: 339/339 passed with this branch's build (2.0.68) dropped into Lean master 154e50720c, following .github/workflows/lean-python-regression-tests.yml.

🤖 Generated with Claude Code

jhonabreul and others added 2 commits October 28, 2026 11:42
Python operators never consult C# implicit conversions, so a type convertible
to decimal or double still raised TypeError on addition, comparison and
float(). Such types now get arithmetic, comparison, __float__ and __int__
methods delegating to the conversion, grafted at class binding the same way
EnumOps is for enums. Operators the type declares itself are kept.
Everything went through double, so integral conversions lost precision past
2**53 and returned floats where Python gives ints. Values now behave like the
Python number their conversion maps to: exact ints for integral conversions
(unbounded, like Python), floats for floating point and decimal ones.

- operands arrive as PyObject: ints beyond Int32 failed the binder's object
  conversion, and numbers implementing __index__ or __float__ (numpy scalars,
  other CLR types with a conversion) are read too; anything else returns
  NotImplemented so Python raises its usual TypeError
- division by zero raises ZeroDivisionError instead of returning infinity
- int() truncates decimals exactly and raises ValueError/OverflowError on
  NaN/infinity
- comparisons between instances of an IComparable type use its own ordering,
  as they did before the number protocol was added
- inherited operators keep winning over the synthesized ones, as in C#;
  OperatorMethod shares the filtered list, or the inherited slot recursed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jhonabreul
jhonabreul force-pushed the feature-implicit-numeric-operators branch from 21bc9de to 70d03fd Compare October 28, 2026 15:43
@jhonabreul
jhonabreul merged commit ba1b2ea into QuantConnect:master Oct 28, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants